Skip to content

Feature/s3 integration#41

Merged
eeebbaandersson merged 10 commits into
mainfrom
feature/s3-integration
Apr 23, 2026
Merged

Feature/s3 integration#41
eeebbaandersson merged 10 commits into
mainfrom
feature/s3-integration

Conversation

@eeebbaandersson

@eeebbaandersson eeebbaandersson commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Passport document upload, view, download (presigned links) and delete in application and edit workflows
    • Local object storage support for attachments (MinIO) and multipart upload limits (10MB)
    • Improved UI for file selection, attached documents listing, and action/workflow forms
  • Tests

    • Tests updated to mock file storage and cover upload, download link generation, and deletion flows

- Added S3Config with S3Client and S3Presigner
- Implemented FileService for unique file uploads and presigned URL generation
- Integrated FileService (S3/MinIO) into visa creation and update flows.
- Implemented logic for handling multipart file uploads and unique S3 key generation.
- Added support for viewing and deleting documents via presigned URLs.
- Refactored VisaDTO and UpdateVisaDTO to bridge backend storage with the UI.
- Enhanced Thymeleaf templates with real-time file selection feedback (JS).
- Fixed Controller rendering issues by ensuring the Model is populated during errors.
- Verified workflow with integration tests for file uploads and validation.
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds MinIO-backed file storage: Docker Compose service and Maven S3 SDK; new S3 configuration beans; a FileService for uploads, presigned URLs, and deletions; VisaService/DTO/mapper/controller changes to store and surface S3 keys and download links; templates updated for multipart file UI; tests updated to mock FileService.

Changes

Cohort / File(s) Summary
Infra & deps
compose.yaml, pom.xml, src/main/resources/application.properties, src/test/resources/application-test.properties
Added minio compose service and minio_data volume; added software.amazon.awssdk:s3 dependency; added MinIO and multipart properties; adjusted DB user default and test properties.
S3 client beans
src/main/java/.../config/S3Config.java
New Spring configuration providing S3Client and S3Presigner beans configured for custom endpoint, static creds, region, and path-style access.
File storage service
src/main/java/.../file/FileService.java
New FileService to initialize bucket/CORS, validate uploads (size/type), upload with UUID keys, generate 10-minute presigned download URLs, and delete objects.
Visa domain & mapping
src/main/java/.../visa/service/VisaService.java, src/main/java/.../visa/mapper/VisaMapper.java, src/main/java/.../visa/dto/VisaDTO.java, src/main/java/.../visa/dto/UpdateVisaDTO.java
Added s3Keys and downloadUrls to VisaDTO; added statusInformation to UpdateVisaDTO; mapper and service updated to accept/store s3Key/newS3Key, build DTOs with presigned URLs, and provide removeVisaDocument.
Controller & templates
src/main/java/.../visa/controller/VisaViewController.java, src/main/resources/templates/visa/apply-form.html, src/main/resources/templates/visa/edit-form.html, src/main/resources/templates/visa/details.html
Controller now injects FileService, accepts optional passportFile multipart uploads on apply/edit, handles upload IO errors, adds document-delete endpoint; templates updated for multipart forms, file input UI, document listing/removal, CSRF tokens, and client-side form handling.
Tests & test infra
src/test/java/.../TestcontainersConfiguration.java, src/test/java/.../VisaCaseManagementSystemVisaTests.java, src/test/java/.../visa/*, src/test/java/.../comment/*, src/test/java/.../user/service/*, src/test/resources/application-test.properties
Testcontainers container genericized and configured; tests add @MockitoBean for FileService, adjust to multipart request simulation, and update DTO/service call signatures and assertions to match new API.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Controller as VisaViewController
    participant FileSvc as FileService
    participant MinIO as S3 (MinIO)
    participant Service as VisaService
    participant DB as Database

    User->>Controller: POST /visas/apply (multipart passportFile)
    Controller->>FileSvc: uploadFile(passportFile)
    FileSvc->>MinIO: PUT object (bucket, uuid-key, fileData)
    MinIO-->>FileSvc: 200 OK
    FileSvc-->>Controller: s3Key
    Controller->>Service: applyForVisa(dto, userId, s3Key)
    Service->>DB: save Visa (with s3Key)
    DB-->>Service: saved
    Service-->>Controller: VisaDTO
    Controller-->>User: confirmation

    User->>Controller: GET /visas/{id}
    Controller->>Service: findVisaDtoById(id)
    Service->>FileSvc: getPresignedDownloadUrl(s3Key)
    FileSvc->>MinIO: generate presigned URL (10m)
    MinIO-->>FileSvc: presignedUrl
    FileSvc-->>Service: presignedUrl
    Service-->>Controller: VisaDTO with downloadUrls
    Controller-->>User: display links

    User->>Controller: POST /visas/{id}/documents/delete (s3Key)
    Controller->>Service: removeVisaDocument(visaId, s3Key, userId)
    Service->>DB: update Visa remove s3Key / set status
    DB-->>Service: updated
    Service->>FileSvc: deleteFile(s3Key)
    FileSvc->>MinIO: DELETE object
    MinIO-->>FileSvc: deleted
    FileSvc-->>Service: success
    Service-->>Controller: done
    Controller-->>User: redirect to edit
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • Martin-E-Karlsson
  • dinbah18

Poem

🐰 I hopped in code through buckets and keys,

Files tucked in MinIO under trees.
Presigned links ready, ten minutes to play,
Uploads, deletes — a hoppity day!
Cheers from a rabbit, with bytes on display.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feature/s3 integration' accurately reflects the main objective of the changeset, which adds comprehensive S3 file storage integration via MinIO to the visa case management system.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/s3-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java (1)

318-336: ⚠️ Potential issue | 🟡 Minor

Assert statusInformation on the service-error edit path.

This test covers the IllegalArgumentException branch but only checks model().hasErrors(). Add the statusInformation assertion so the edit page feedback banner stays covered. Based on learnings, processUpdate() should add statusInformation when visaService.updateVisa() throws IllegalArgumentException so edit-form.html renders the feedback banner correctly.

💚 Proposed test assertion
         result.andExpect(status().isOk())
                 .andExpect(view().name("visa/edit-form"))
-                .andExpect(model().hasErrors());
+                .andExpect(model().hasErrors())
+                .andExpect(model().attributeExists("statusInformation"));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java`
around lines 318 - 336, The test for the IllegalArgumentException branch (in
VisaViewControllerTest) currently asserts view name and model errors but is
missing a check that the controller sets the statusInformation model attribute
when visaService.updateVisa(...) throws; update the multipart "/visas/{id}/edit"
test to include an expectation like model().attributeExists("statusInformation")
(or a specific value) after the existing andExpect calls so the edit-form
feedback banner is covered; this relates to the processUpdate() path that should
add the "statusInformation" attribute when updateVisa throws
IllegalArgumentException.
src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java (2)

189-217: ⚠️ Potential issue | 🟠 Major

Orphaned S3 objects if applyForVisa/updateVisa fails after upload.

The fileService.uploadFile(...) call happens in the controller before this @Transactional service method runs (see VisaViewController.submitApplication Lines 101–105 and processUpdate Lines 175–179). If visaRepository.save(visa) here throws (constraint violation, connection lost, validation, etc.), the transaction rolls back but the S3 object remains — you accumulate orphaned uploads that are never referenced from the DB.

Consider one of:

  • Performing the upload inside a service method and, on DB-commit failure, compensating with fileService.deleteFile(...) in a try/catch around the save.
  • Using TransactionSynchronizationManager.registerSynchronization with afterCompletion(status == ROLLED_BACK) to schedule the S3 delete.
  • A janitor job that reconciles unreferenced keys in the bucket against visa_documents.

Same concern applies to updateVisa (Lines 220–259).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`
around lines 189 - 217, The controller currently uploads files
(fileService.uploadFile) before calling the transactional service methods
applyForVisa and updateVisa, causing orphaned S3 objects if visaRepository.save
rolls back; modify the service methods (applyForVisa and updateVisa in
VisaService) to either perform the upload inside the transactional boundary or
register a rollback callback to delete the uploaded key(s): either move the
upload into VisaService (so you can catch save failures and call
fileService.deleteFile on failure) or use
TransactionSynchronizationManager.registerSynchronization to run
fileService.deleteFile when afterCompletion indicates ROLLED_BACK; ensure
auditService.createAuditLog and visaRepository.save remain unchanged except for
adding the compensating delete or synchronization logic to clean up s3Key(s).

220-259: ⚠️ Potential issue | 🟡 Minor

Align blank-check for consistency and defensive programming.

Two observations:

  1. applyForVisa guards with s3Key != null && !s3Key.isBlank(), but updateVisa only checks newS3Key != null. Align the blank-check to prevent edge cases where an empty string could be added to s3Keys.
  2. The updateVisa method appends new documents without removing prior uploads—the list and S3 storage grow with each edit. The UI supports multiple attachments (labeled "View Attachment 1", "View Attachment 2", etc.) and allows manual deletion, suggesting accumulation is intentional. Confirm whether this behavior is desired; if documents should be replaced on re-upload instead, add cleanup logic here.
♻️ Align blank-check
-        if (newS3Key != null) {
+        if (newS3Key != null && !newS3Key.isBlank()) {
             visa.getS3Keys().add(newS3Key);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`
around lines 220 - 259, The updateVisa method currently adds newS3Key when
newS3Key != null, but applyForVisa guards with s3Key != null &&
!s3Key.isBlank(), so in updateVisa (method updateVisa, symbol newS3Key and
visa.getS3Keys()) change the guard to check for non-blank strings (e.g.,
newS3Key != null && !newS3Key.isBlank()) to avoid adding empty keys;
additionally decide whether accumulation is intended: if attachments should
replace prior upload, implement cleanup in updateVisa by removing existing
entries from visa.getS3Keys() and deleting those objects from S3 (use your S3
client) before adding the new key and saving; if accumulation is intended, leave
the list-as-append behavior but add the blank check above to prevent empty
entries.
♻️ Duplicate comments (1)
src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java (1)

85-120: ⚠️ Potential issue | 🟠 Major

Upload-then-persist ordering can orphan S3 objects.

When the multipart upload succeeds (Line 102) but visaService.applyForVisa(...) later fails (e.g., DB constraint, travel-date race), the uploaded object remains in S3 with no DB reference. Same pattern in processUpdate (Lines 175–179). The root-cause note and a suggested fix are in the VisaService review; from the controller's side, consider delegating the upload into the service so both operations live under a single transactional/compensation boundary, or at minimum call fileService.deleteFile(s3Key) in the catch blocks before returning the form.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java`
around lines 85 - 120, The controller currently uploads to S3 before calling
visaService.applyForVisa in submitApplication (and similarly in processUpdate),
which can leave orphaned S3 objects if the DB operation fails; either move the
upload logic into visaService.applyForVisa/processUpdate so the service can
manage the upload within its transactional/compensation boundary, or if you keep
upload in the controller, ensure any uploaded s3Key is cleaned up on failure by
calling fileService.deleteFile(s3Key) (guarded by s3Key != null) in each catch
block (IOException, IllegalArgumentException and any other failure paths) before
returning the form; update submitApplication and processUpdate accordingly so S3
cleanup always happens on error.
🧹 Nitpick comments (15)
src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java (1)

80-101: Consider adding a test for the new statusInformation mapping behavior.

The mapper now conditionally sets statusInformation only when non-null (VisaMapper.java:59-61). This test only exercises the null branch, leaving the non-null case uncovered. Worth adding an assertion that (a) a non-null statusInformation in the DTO is written through, and (b) a null value does not overwrite a pre-existing value on existingVisa.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java`
around lines 80 - 101, Add two assertions and a new test case exercising
VisaMapper.updateEntityFromDTO's statusInformation branch: create an
UpdateVisaDTO with a non-null statusInformation and assert
existingVisa.getStatusInformation() is updated; also assert in the current test
(or a new test) that when DTO.statusInformation() is null an existing non-null
existingVisa.statusInformation remains unchanged. Reference the UpdateVisaDTO
construction and the visaMapper.updateEntityFromDTO(...) call and assert against
existingVisa.getStatusInformation() to cover both non-null and null branches.
src/main/resources/application.properties (1)

21-22: Consider aligning multipart limits with S3/Tomcat expectations.

10MB is reasonable for passport scans, but VisaViewController accepts the upload synchronously and streams through the service. If you ever accept larger documents, also tune server.tomcat.max-swallow-size and server.tomcat.max-http-form-post-size, otherwise requests exceeding those (defaults 2MB/2MB respectively in some configurations) will fail before Spring enforces the multipart limit. Not a blocker at 10MB.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/application.properties` around lines 21 - 22, The
multipart size properties spring.servlet.multipart.max-file-size and
spring.servlet.multipart.max-request-size are set to 10MB but Tomcat can reject
larger uploads first; update configuration to also set
server.tomcat.max-swallow-size and server.tomcat.max-http-form-post-size to
values equal to or larger than the multipart limits (or to -1 for unlimited) so
uploads accepted by VisaViewController’s synchronous streaming won’t be dropped
by Tomcat before Spring processes them; ensure the same limit is applied to both
max-file-size and max-request-size and document the chosen values.
compose.yaml (1)

11-24: Local-dev credentials and healthcheck.

Two small improvements for the MinIO service:

  1. Parameterize credentials via .env so they aren't duplicated between compose.yaml and application.properties, and so prod-ish environments don't silently reuse admin/password.
  2. Add a healthcheck so dependent services (app container, future init jobs) can wait for MinIO to be ready before FileService's bucket-init runs. mc ready local or an HTTP probe on /minio/health/ready both work.
♻️ Suggested change
   minio:
     image: 'minio/minio:latest'
     ports:
       - '9000:9000'
       - '9001:9001'
     environment:
-      - MINIO_ROOT_USER=admin
-      - MINIO_ROOT_PASSWORD=password
+      - MINIO_ROOT_USER=${MINIO_ROOT_USER:-admin}
+      - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-password}
     command: server /data --console-address ":9001"
     volumes:
       - minio_data:/data
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@compose.yaml` around lines 11 - 24, The MinIO service currently hardcodes
credentials and lacks a readiness probe; change the minio service to read
MINIO_ROOT_USER and MINIO_ROOT_PASSWORD from environment variables (use
${MINIO_ROOT_USER} / ${MINIO_ROOT_PASSWORD} with sensible defaults via .env)
instead of fixed "admin"/"password" so they can be shared with
application.properties, and add a healthcheck block for the minio service (e.g.,
using "healthcheck" with an HTTP probe to /minio/health/ready or an sh -c 'mc
alias set ... && mc admin info local' / 'mc ready local' command) so dependent
services and FileService bucket-init wait until MinIO is ready; update .env with
the variables and ensure application.properties references the same env vars.
pom.xml (1)

156-160: Prefer AWS SDK BOM over pinning a single artifact version.

Importing software.amazon.awssdk:bom via <dependencyManagement> keeps all AWS SDK modules (core, regions, auth, http-client, s3, etc.) aligned on one version and avoids subtle transitive-version mismatches as you add more SDK modules. Additionally, version 2.42.30 is now the latest stable release; update from 2.42.20.

♻️ Suggested structure
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>software.amazon.awssdk</groupId>
+                <artifactId>bom</artifactId>
+                <version>2.42.30</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
...
         <dependency>
             <groupId>software.amazon.awssdk</groupId>
             <artifactId>s3</artifactId>
-            <version>2.42.20</version>
         </dependency>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pom.xml` around lines 156 - 160, Replace the hard-pinned
software.amazon.awssdk:s3 dependency version with a BOM-based approach: add
software.amazon.awssdk:bom at version 2.42.30 under dependencyManagement (import
scope) and remove the <version> element from the existing
<artifactId>s3</artifactId> dependency so the S3 module inherits the BOM-managed
version; ensure any other AWS SDK modules follow the same pattern so all SDK
modules (core, regions, auth, http-client, s3, etc.) remain aligned.
src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java (1)

129-148: Stub and verify the S3 key flow in the upload tests.

The tests at lines 129–148 and 224–245 attach passportFile but use any() for the S3 key parameter, allowing the controller to skip FileService.uploadFile() entirely (or pass null) and still pass. Stub fileService.uploadFile() to return a specific key, verify the upload was called, and assert that exact key reaches VisaService.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java`
around lines 129 - 148, The test currently uses any() for the S3 key allowing
the controller to skip or ignore FileService.uploadFile(); update the test to
stub fileService.uploadFile(...) to return a fixed S3 key (e.g.
"s3://bucket/key.pdf"), assert fileService.uploadFile was called with the
MockMultipartFile (passportFile), and change the verify of
visaService.applyForVisa(...) to assert that the exact returned S3 key is passed
into the CreateVisaDTO (or passed as the third parameter) instead of any();
locate MockMultipartFile creation, the mockMvc.perform multipart call, and the
verify(visaService).applyForVisa(...) in this test and the similar block at
lines ~224–245 to add the when(...).thenReturn(...) for fileService.uploadFile,
a verify(fileService).uploadFile(...) call, and a stricter verification of the
S3 key.
src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java (1)

15-18: Pin the PostgreSQL image instead of latest.

postgres:latest is mutable; CI can silently move to a new major version and make Testcontainers runs non-reproducible. Pin the PostgreSQL major/minor version that matches production.

♻️ Example pin, if PostgreSQL 16 matches the supported runtime
-        return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest"))
+        return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
                 .withDatabaseName("testdb")
                 .withUsername("test")
                 .withPassword("test");

Run this read-only check after updating to ensure mutable tags are gone:

#!/bin/bash
# Description: Find mutable postgres image tags in test configuration.
rg -n -C2 'postgres:latest|DockerImageName\.parse\("postgres:' 
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java`
around lines 15 - 18, The Testcontainers configuration is using a mutable image
tag ("postgres:latest") in the PostgreSQLContainer creation inside
TestcontainersConfiguration; replace DockerImageName.parse("postgres:latest")
with a pinned major.minor production-matching tag (e.g., "postgres:16.2" or the
exact version you run in prod) or load that pinned tag from a test
config/property, keeping the same PostgreSQLContainer construction and
.withDatabaseName/.withUsername/.withPassword calls; after updating, run the
provided grep/ripgrep check to ensure no remaining mutable postgres tags remain.
src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java (1)

61-89: findVisaDtoById rebuilds the entire VisaDTO just to inject presigned URLs.

This manual re-enumeration of every field is fragile: any new field added to VisaDTO must be remembered here or the detail view silently loses it. It also duplicates responsibility with VisaMapper.toDTO (which sets downloadUrls to List.of() for the exact reason that this method will replace them).

A cleaner approach is to push the "enrich with presigned URLs" step into a small helper (or a VisaMapper.toDtoWithUrls(visa, fileService) overload), so only one code path constructs VisaDTO end-to-end.

♻️ Proposed fix sketch
-    public VisaDTO findVisaDtoById(Long id) {
-        Visa visa = visaRepository.findById(id)
-                .orElseThrow(() -> new EntityNotFoundException(NOT_FOUND_MESSAGE));
-
-        VisaDTO dto = visaMapper.toDTO(visa);
-
-        List<String> presignedUrls = visa.getS3Keys().stream()
-                .map(fileService::getPresignedDownloadUrl)
-                .toList();
-
-        return new VisaDTO(
-                dto.id(), dto.visaType(), dto.visaStatus(), dto.nationality(),
-                dto.passportNumber(), dto.travelDate(), dto.applicantId(),
-                dto.applicantName(), dto.handlerId(), dto.handlerName(),
-                dto.createdAt(), dto.updatedAt(), dto.statusInformation(),
-                presignedUrls, dto.s3Keys()
-        );
-    }
+    public VisaDTO findVisaDtoById(Long id) {
+        Visa visa = visaRepository.findById(id)
+                .orElseThrow(() -> new EntityNotFoundException(NOT_FOUND_MESSAGE));
+        return visaMapper.toDtoWithDownloadUrls(visa, fileService::getPresignedDownloadUrl);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`
around lines 61 - 89, The findVisaDtoById method manually rebuilds VisaDTO to
set presignedUrls, which is fragile and duplicates VisaMapper.toDTO; instead add
a single helper such as VisaMapper.toDtoWithUrls(Visa visa, FileService
fileService) (or a small private method mapWithUrls) that calls toDTO(visa) and
replaces/downloads the presigned URLs by mapping visa.getS3Keys() via
fileService.getPresignedDownloadUrl, then return that DTO from findVisaDtoById;
update findVisaDtoById to call the new mapper/helper and remove the manual
reconstruction so future VisaDTO fields don’t need to be mirrored here.
src/main/java/org/example/visacasemanagementsystem/config/S3Config.java (2)

18-28: Consider @ConfigurationProperties and secret-hardening for S3 credentials.

Four individual @Value injections duplicated across two beans work, but a single @ConfigurationProperties("minio") record/class would centralize the config and make it easier to validate (e.g., @NotBlank on endpoint, accessKey, secretKey). Also flag: plain accessKey/secretKey coming from properties are fine for local MinIO, but for any non-dev deployment these should come from an env/secret store (SSM, Vault, K8s secrets) rather than be bundled into application.properties.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/config/S3Config.java`
around lines 18 - 28, Replace the scattered `@Value` injections in S3Config with a
single `@ConfigurationProperties`(prefix="minio")-annotated class or record (e.g.,
MinioProperties) that declares endpoint, accessKey, secretKey, region with
validation annotations like `@NotBlank`; inject that MinioProperties into S3Config
and the other bean instead of duplicating fields, add `@Validated` on the
configuration class to enforce constraints, and update documentation/config so
accessKey/secretKey are sourced from environment/secret-management (SSM, Vault,
or K8s secrets) for non-dev deployments rather than stored in
application.properties.

38-42: Remove stray commented-out code.

The checksumValidationEnabled(false) line on Line 40 is commented out. If it's not needed, remove it to keep the configuration clean; if it was intended to be enabled to work around MinIO's handling of AWS SDK v2 default integrity checks, make it explicit rather than leaving it as a dead artifact.

♻️ Suggested cleanup
                 .serviceConfiguration(S3Configuration.builder()
                         .pathStyleAccessEnabled(true)
-//                        .checksumValidationEnabled(false)
                         .build())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/config/S3Config.java`
around lines 38 - 42, In S3Config update the S3Configuration builder call:
either delete the commented-out ".checksumValidationEnabled(false)" line to
remove dead code, or make the intent explicit by uncommenting and setting
checksumValidationEnabled(false) on the S3Configuration builder
(S3Configuration.builder() ... .checksumValidationEnabled(false) ... .build())
if you need to disable SDK integrity checks for MinIO; ensure the change is
applied where S3Configuration is constructed inside the S3Config class.
src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java (2)

189-203: Brittle message-string branching for IllegalArgumentException.

e.getMessage().contains("date") is a fragile way to distinguish travel-date errors from other IllegalArgumentExceptions thrown by the service (e.g., "Mismatched visa id.", "This application can no longer be edited."). Any wording change in VisaService silently flips the error from a field error on travelDate to a global error (or vice versa). Prefer a dedicated exception type (e.g., InvalidTravelDateException) or return codes, and branch on the type rather than the message text.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java`
around lines 189 - 203, The current catch in VisaViewController relies on
inspecting IllegalArgumentException.getMessage() to detect travel-date errors
(e.getMessage().contains("date")), which is brittle; update the service and
controller to use a dedicated exception type instead: add an
InvalidTravelDateException (or similar) thrown by VisaService when the travel
date is invalid, then in the try/catch replace the message-inspection branch
with a catch(InvalidTravelDateException e) that calls
bindingResult.rejectValue("travelDate", "error.travelDate", e.getMessage());
keep a separate catch(IllegalArgumentException e) to handle other
illegal-argument cases and call bindingResult.reject("globalError",
e.getMessage()); ensure prepareApplyModel(principal.getUserId(), model),
visaService.findVisaDtoById(id) and the model attributes remain as-is in both
branches.

208-216: Document-delete endpoint has no user-visible error feedback on failure.

visaService.removeVisaDocument(...) can throw UnauthorizedException or silently no-op if the key isn't attached. The controller simply redirects to /visas/{id}/edit either way; the user sees no confirmation on success nor an error on failure. Consider using RedirectAttributes.addFlashAttribute(...) to surface both outcomes on the edit page. Also worth wrapping in a try/catch around EntityNotFoundException so a bad s3Key returns a friendly message instead of a 500 page via the global exception handler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java`
around lines 208 - 216, Update the deleteVisaDocument controller to catch and
surface outcomes to the user: wrap the call to
visaService.removeVisaDocument(id, s3Key, principal.getUserId()) in a try/catch
that handles UnauthorizedException and EntityNotFoundException (and a generic
Exception fallback), add a RedirectAttributes parameter to deleteVisaDocument,
and use redirectAttributes.addFlashAttribute(...) to add a success message when
removal succeeds or an error message when exceptions occur before returning
"redirect:/visas/" + id + "/edit"; ensure UnauthorizedException produces a clear
permission error and EntityNotFoundException produces a friendly "document not
found" message rather than letting the global handler return 500.
src/main/java/org/example/visacasemanagementsystem/file/FileService.java (2)

89-102: responseContentDisposition uses the raw S3 key as the filename.

The key is UUID + "_" + original, so users downloading always see the UUID prefix in their file name, and if original ever contained a " or newline (upstream sanitization would help, see the uploadFile comment), it would break the Content-Disposition header. Consider storing the original filename as S3 object metadata at upload time and reading it back here (RFC 5987-encoded) so downloads present the original, safe filename.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`
around lines 89 - 102, getPresignedDownloadUrl currently uses the raw S3 key
(UUID_original) in the Content-Disposition; instead, store the original filename
as object metadata at upload (e.g., metadata key "original-filename" in your
uploadFile logic) and read that metadata here before building the presign. Call
HeadObjectRequest/HeadObjectResponse (via your s3 client) to fetch metadata
inside getPresignedDownloadUrl, fallback to the key if metadata is missing,
RFC5987-encode the filename (UTF-8''percent-encoded) and ensure any
quotes/newlines are sanitized, then set responseContentDisposition using
filename*=UTF-8''encodedName so downloads show the safe original filename when
you build the GetObjectRequest for s3Presigner.

41-53: Bucket verification swallows non-404 errors, then still attempts CORS configuration.

If headBucket fails with a non-404 S3Exception that's not rethrown into the outer catch (e.g., network error, auth error, region redirect), the outer catch (Exception e) logs and swallows, and execution falls through to the CORS block which will almost certainly also fail. Consider return-ing after a failed verify/create so CORS isn't attempted on an unverified bucket, and consider a health-indicator so operators can see the bucket wasn't provisioned rather than discovering it on the first user upload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`
around lines 41 - 53, The try/catch around s3Client.headBucket(...) swallows
non-404 S3Exception into the outer catch and lets execution continue to the CORS
configuration; update the FileService startup flow so that if headBucket fails
for any non-404 S3Exception (or any exception when verifying/creating the
bucket) you log the error and abort further bucket setup (e.g., return or throw
to stop executing the CORS/configuration block) instead of falling through, and
add or wire a health indicator/flag (e.g., bucketProvisioned boolean or health
check method) that is set only after successful s3Client.createBucket(...) or
headBucket verification so operators can detect an unprovisioned bucket.
src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java (2)

59-61: Conditional set blocks clearing statusInformation via updateEntityFromDTO.

dto.statusInformation() != null means this mapper can never clear a previously set status message through the DTO; callers that want to clear it must do so out-of-band (as VisaService.updateVisa currently does at Line 248 via visa.setStatusInformation(null)). That's fine for the current flow, but be aware that any future caller expecting "null in DTO means clear" will silently keep the old value. If the intent is "only update when the DTO explicitly carries a value", that's okay — just flagging the semantic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java`
around lines 59 - 61, The current updateEntityFromDTO uses if
(dto.statusInformation() != null) to skip setting statusInformation when the DTO
value is null, which prevents callers from clearing an existing value; to fix,
either (A) always call visa.setStatusInformation(dto.statusInformation()) in
updateEntityFromDTO so a null in the DTO clears the field, or (B) add an
explicit presence indicator to the DTO (e.g., hasStatusInformation() or
Optional<String> statusInformation()) and update updateEntityFromDTO to set
visa.setStatusInformation(...) only when that presence flag indicates the caller
intended to update; update callers accordingly (e.g., VisaService.updateVisa) to
use the chosen approach.

15-35: toDTO() returns a VisaDTO with empty downloadUrls, which is then reconstructed in VisaService.findVisaDtoById — fragile split responsibility.

The mapper always sets downloadUrls to List.of() and findVisaDtoById then builds a brand-new VisaDTO copying every field just to replace downloadUrls with presigned URLs (see VisaService.findVisaDtoById Lines 61–89). This has two consequences:

  1. Any other call site that uses visaMapper.toDTO(visa) directly (e.g., findAll, findVisasByApplicant, findVisaByType, findVisaByStatus, findVisaByDateCreated, etc.) silently returns visas with empty downloadUrls, which is a trap for future callers/templates.
  2. The reconstruction in findVisaDtoById duplicates every field and will break silently if a new field is added to VisaDTO and forgotten there.

Consider either (a) passing a Function<String, String> s3KeyToUrl (or the FileService) into a dedicated mapper overload that populates downloadUrls in one place, or (b) using a wither-style helper like dto.withDownloadUrls(urls) so findVisaDtoById doesn't manually re-enumerate all fields.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java`
around lines 15 - 35, The mapper VisaMapper.toDTO currently sets downloadUrls to
an empty list causing callers to receive incomplete DTOs and forcing
VisaService.findVisaDtoById to reconstruct the entire VisaDTO; change the design
by either (A) adding an overload VisaMapper.toDTO(Visa visa,
Function<String,String> s3KeyToUrl) or toDTO(Visa visa, FileService fileService)
that maps visa.getS3Keys() -> presigned URLs and populates downloadUrls there,
or (B) add a withDownloadUrls(List<String>) method on VisaDTO (a wither) and
have VisaService.findVisaDtoById call visaMapper.toDTO(visa) and then
dto.withDownloadUrls(urls) instead of reconstructing the DTO; update all call
sites (findAll, findVisasByApplicant, findVisaByType, etc.) to use the
appropriate overload or wither so downloadUrls is consistently populated in one
place (refer to VisaMapper.toDTO, VisaService.findVisaDtoById, VisaDTO).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`:
- Around line 56-70: The CORS rule is overly permissive: replace
allowedOrigins("*") and the wide allowedMethods list with a
configuration-driven, environment-safe policy—read allowed front-end origins
from configuration/env (e.g., FRONTEND_ORIGINS) and use those values in CORSRule
(instead of "*"), and restrict allowedMethods to the minimal set required for
browser interactions (e.g., "GET" and "HEAD", or include "POST" only if browser
uploads are used); update the block that builds CORSRule/CORSConfiguration
(references: CORSRule.builder(), allowedOrigins(), allowedMethods(),
PutBucketCorsRequest.builder(), bucketName, s3Client) to apply the tighter
policy and keep the permissive fallback only for local/dev (e.g., when
bucketName == "test-bucket" or an explicit env flag).
- Around line 73-87: In uploadFile(MultipartFile file) sanitize and validate
inputs before calling s3Client.putObject: treat file.getOriginalFilename() as
nullable and untrusted (handle null, strip path separators, control chars and
non-printable Unicode, and normalize/limit length) when building fileName;
validate file.getSize() against a configured maxFileSize (inject/config
property) and reject/throw if exceeded; validate file.getContentType() against
an allowlist (or detect/override using server-side detection) and do not blindly
echo an unverified content type into the S3 metadata; ensure you fail fast with
a clear exception for invalid files and use the sanitized fileName and validated
contentType when creating PutObjectRequest in uploadFile.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`:
- Line 214: The audit message in VisaService (where the string is built using
s3Key) misses a space and yields "Visa application submitted.Document
attached."; update the concatenation to include a space before "Document
attached." (e.g., change "Visa application submitted." + (s3Key != null ?
"Document attached." : "") to "Visa application submitted." + (s3Key != null ? "
Document attached." : "")) so s3Key-based messages read correctly.
- Around line 261-286: In removeVisaDocument, if visa.getS3Keys().remove(s3Key)
returns false you must surface that as an error instead of silently no-op: check
presence of the s3Key on the Visa (visa.getS3Keys()) and if absent throw an
EntityNotFoundException (e.g. new EntityNotFoundException("Document not attached
to visa: " + s3Key)); only call visaRepository.save(visa),
fileService.deleteFile(s3Key) and auditService.createAuditLog(...) when the key
was actually removed. Keep these changes inside the removeVisaDocument method so
ownership checks (isOwner/isAdmin), the status update, deletion and audit remain
unchanged when the key exists.

In `@src/main/resources/application.properties`:
- Around line 15-22: Remove the hard-coded MinIO credentials by replacing the
literal values for minio.accessKey and minio.secretKey with required Spring
placeholders that read from environment variables (e.g., ${MINIO_ACCESS_KEY} and
${MINIO_SECRET_KEY}) and fail fast if not provided (mirror the
spring.datasource.* pattern), ensure minio.endpoint/minio.bucketName remain
configurable but do not provide insecure defaults for secrets, and add
local/dev-only defaults in application-test.properties or a dev .env file
instead of committing them in this file.

In `@src/main/resources/templates/visa/apply-form.html`:
- Around line 215-223: The file-name display uses innerHTML with user-controlled
this.files[0].name, which is unsafe; in the event listener attached to
document.getElementById('passportFile') replace both assignments to
fileNameDisplay.innerHTML with fileNameDisplay.textContent (keep the same '📎
Selected: ' prefix for the first case and set an empty string for the else
branch) so the filename is rendered as plain text rather than parsed HTML.

In `@src/main/resources/templates/visa/details.html`:
- Around line 196-199: The "No documents attached" warning is shown to all
viewers; change the template logic to show the red/warning variant only for
regular users by checking currentUser.userAuthorization.name() == 'USER' and
render a neutral/info message for admins otherwise. Locate the existing
th:if="${`#lists.isEmpty`(visa.downloadUrls)}" block in the visa details template
and replace it with a role-gated conditional (use th:if to check empty
downloadUrls AND currentUser.userAuthorization.name() == 'USER' for the red
warning), plus an else/alternate block (e.g., th:if for admin or th:unless for
the USER check) that displays a neutral, non-alarming message when downloadUrls
is empty and the viewer is an admin.
- Around line 186-194: The links all show the hardcoded text "View Passport
Copy" because the template's th:each over visa.downloadUrls doesn't use any
per-item label; update the template loop (th:each="url,iter :
${visa.downloadUrls}") to use an index or pair with a parallel collection of
filenames returned from the backend (e.g., visa.s3Keys or visa.filenames) and
render a per-file label like the original filename or a contextual label using
the index (e.g., "View {filename}" or "View document #{iter.index+1}"); if
filenames are not yet returned, modify the backend to include a list of original
filenames (or a map of url->name) on the visa DTO so the template can display
distinct labels.
- Around line 365-376: The code creates a comment card using card.innerHTML and
directly interpolates comment.authorName and comment.text, which enables XSS;
instead, build the DOM using safe text nodes or set textContent for the author
and text (e.g., create elements for the .comment-meta and .comment-text, assign
their textContent from comment.authorName and comment.text, append them to card,
then container.prepend(card)) so no unescaped HTML from
comment.authorName/comment.text is inserted via innerHTML.

In `@src/main/resources/templates/visa/edit-form.html`:
- Around line 238-246: Change the file-name update to avoid HTML injection by
using textContent instead of innerHTML in the change handler attached to the
element with id 'passportFile' (the anonymous function passed to
document.getElementById('passportFile').addEventListener('change', ...)); set
fileNameDisplay.textContent = '📎 Selected: ' + this.files[0].name (and clear
with textContent = '') so the user-controlled this.files[0].name is not
interpreted as HTML.
- Around line 196-199: The anchor tag that renders presigned document links (the
<a> element with th:href="${url}" and the nested <span th:text="'View Attachment
' + ${iter.count}">) uses target="_blank" but lacks a rel attribute; update that
anchor to include rel="noopener noreferrer" to prevent the new tab from
accessing window.opener and mitigate security/privacy issues.
- Around line 229-234: The delete endpoint currently trusts the client-supplied
s3Key; update the service method that handles document deletion (the method
which calls fileService.deleteFile(s3Key)) to first verify ownership by checking
that the target Visa's collection contains the key (e.g., if
(!visa.getS3Keys().contains(s3Key)) throw new IllegalArgumentException("s3Key
does not belong to visa") or a domain-specific exception), and only then call
fileService.deleteFile(s3Key) and remove the key from visa; reference the Visa
entity's getS3Keys(), the service delete method that invokes
fileService.deleteFile, and ensure you persist the updated Visa after removal.

---

Outside diff comments:
In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`:
- Around line 189-217: The controller currently uploads files
(fileService.uploadFile) before calling the transactional service methods
applyForVisa and updateVisa, causing orphaned S3 objects if visaRepository.save
rolls back; modify the service methods (applyForVisa and updateVisa in
VisaService) to either perform the upload inside the transactional boundary or
register a rollback callback to delete the uploaded key(s): either move the
upload into VisaService (so you can catch save failures and call
fileService.deleteFile on failure) or use
TransactionSynchronizationManager.registerSynchronization to run
fileService.deleteFile when afterCompletion indicates ROLLED_BACK; ensure
auditService.createAuditLog and visaRepository.save remain unchanged except for
adding the compensating delete or synchronization logic to clean up s3Key(s).
- Around line 220-259: The updateVisa method currently adds newS3Key when
newS3Key != null, but applyForVisa guards with s3Key != null &&
!s3Key.isBlank(), so in updateVisa (method updateVisa, symbol newS3Key and
visa.getS3Keys()) change the guard to check for non-blank strings (e.g.,
newS3Key != null && !newS3Key.isBlank()) to avoid adding empty keys;
additionally decide whether accumulation is intended: if attachments should
replace prior upload, implement cleanup in updateVisa by removing existing
entries from visa.getS3Keys() and deleting those objects from S3 (use your S3
client) before adding the new key and saving; if accumulation is intended, leave
the list-as-append behavior but add the blank check above to prevent empty
entries.

In
`@src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java`:
- Around line 318-336: The test for the IllegalArgumentException branch (in
VisaViewControllerTest) currently asserts view name and model errors but is
missing a check that the controller sets the statusInformation model attribute
when visaService.updateVisa(...) throws; update the multipart "/visas/{id}/edit"
test to include an expectation like model().attributeExists("statusInformation")
(or a specific value) after the existing andExpect calls so the edit-form
feedback banner is covered; this relates to the processUpdate() path that should
add the "statusInformation" attribute when updateVisa throws
IllegalArgumentException.

---

Duplicate comments:
In
`@src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java`:
- Around line 85-120: The controller currently uploads to S3 before calling
visaService.applyForVisa in submitApplication (and similarly in processUpdate),
which can leave orphaned S3 objects if the DB operation fails; either move the
upload logic into visaService.applyForVisa/processUpdate so the service can
manage the upload within its transactional/compensation boundary, or if you keep
upload in the controller, ensure any uploaded s3Key is cleaned up on failure by
calling fileService.deleteFile(s3Key) (guarded by s3Key != null) in each catch
block (IOException, IllegalArgumentException and any other failure paths) before
returning the form; update submitApplication and processUpdate accordingly so S3
cleanup always happens on error.

---

Nitpick comments:
In `@compose.yaml`:
- Around line 11-24: The MinIO service currently hardcodes credentials and lacks
a readiness probe; change the minio service to read MINIO_ROOT_USER and
MINIO_ROOT_PASSWORD from environment variables (use ${MINIO_ROOT_USER} /
${MINIO_ROOT_PASSWORD} with sensible defaults via .env) instead of fixed
"admin"/"password" so they can be shared with application.properties, and add a
healthcheck block for the minio service (e.g., using "healthcheck" with an HTTP
probe to /minio/health/ready or an sh -c 'mc alias set ... && mc admin info
local' / 'mc ready local' command) so dependent services and FileService
bucket-init wait until MinIO is ready; update .env with the variables and ensure
application.properties references the same env vars.

In `@pom.xml`:
- Around line 156-160: Replace the hard-pinned software.amazon.awssdk:s3
dependency version with a BOM-based approach: add software.amazon.awssdk:bom at
version 2.42.30 under dependencyManagement (import scope) and remove the
<version> element from the existing <artifactId>s3</artifactId> dependency so
the S3 module inherits the BOM-managed version; ensure any other AWS SDK modules
follow the same pattern so all SDK modules (core, regions, auth, http-client,
s3, etc.) remain aligned.

In `@src/main/java/org/example/visacasemanagementsystem/config/S3Config.java`:
- Around line 18-28: Replace the scattered `@Value` injections in S3Config with a
single `@ConfigurationProperties`(prefix="minio")-annotated class or record (e.g.,
MinioProperties) that declares endpoint, accessKey, secretKey, region with
validation annotations like `@NotBlank`; inject that MinioProperties into S3Config
and the other bean instead of duplicating fields, add `@Validated` on the
configuration class to enforce constraints, and update documentation/config so
accessKey/secretKey are sourced from environment/secret-management (SSM, Vault,
or K8s secrets) for non-dev deployments rather than stored in
application.properties.
- Around line 38-42: In S3Config update the S3Configuration builder call: either
delete the commented-out ".checksumValidationEnabled(false)" line to remove dead
code, or make the intent explicit by uncommenting and setting
checksumValidationEnabled(false) on the S3Configuration builder
(S3Configuration.builder() ... .checksumValidationEnabled(false) ... .build())
if you need to disable SDK integrity checks for MinIO; ensure the change is
applied where S3Configuration is constructed inside the S3Config class.

In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`:
- Around line 89-102: getPresignedDownloadUrl currently uses the raw S3 key
(UUID_original) in the Content-Disposition; instead, store the original filename
as object metadata at upload (e.g., metadata key "original-filename" in your
uploadFile logic) and read that metadata here before building the presign. Call
HeadObjectRequest/HeadObjectResponse (via your s3 client) to fetch metadata
inside getPresignedDownloadUrl, fallback to the key if metadata is missing,
RFC5987-encode the filename (UTF-8''percent-encoded) and ensure any
quotes/newlines are sanitized, then set responseContentDisposition using
filename*=UTF-8''encodedName so downloads show the safe original filename when
you build the GetObjectRequest for s3Presigner.
- Around line 41-53: The try/catch around s3Client.headBucket(...) swallows
non-404 S3Exception into the outer catch and lets execution continue to the CORS
configuration; update the FileService startup flow so that if headBucket fails
for any non-404 S3Exception (or any exception when verifying/creating the
bucket) you log the error and abort further bucket setup (e.g., return or throw
to stop executing the CORS/configuration block) instead of falling through, and
add or wire a health indicator/flag (e.g., bucketProvisioned boolean or health
check method) that is set only after successful s3Client.createBucket(...) or
headBucket verification so operators can detect an unprovisioned bucket.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java`:
- Around line 189-203: The current catch in VisaViewController relies on
inspecting IllegalArgumentException.getMessage() to detect travel-date errors
(e.getMessage().contains("date")), which is brittle; update the service and
controller to use a dedicated exception type instead: add an
InvalidTravelDateException (or similar) thrown by VisaService when the travel
date is invalid, then in the try/catch replace the message-inspection branch
with a catch(InvalidTravelDateException e) that calls
bindingResult.rejectValue("travelDate", "error.travelDate", e.getMessage());
keep a separate catch(IllegalArgumentException e) to handle other
illegal-argument cases and call bindingResult.reject("globalError",
e.getMessage()); ensure prepareApplyModel(principal.getUserId(), model),
visaService.findVisaDtoById(id) and the model attributes remain as-is in both
branches.
- Around line 208-216: Update the deleteVisaDocument controller to catch and
surface outcomes to the user: wrap the call to
visaService.removeVisaDocument(id, s3Key, principal.getUserId()) in a try/catch
that handles UnauthorizedException and EntityNotFoundException (and a generic
Exception fallback), add a RedirectAttributes parameter to deleteVisaDocument,
and use redirectAttributes.addFlashAttribute(...) to add a success message when
removal succeeds or an error message when exceptions occur before returning
"redirect:/visas/" + id + "/edit"; ensure UnauthorizedException produces a clear
permission error and EntityNotFoundException produces a friendly "document not
found" message rather than letting the global handler return 500.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java`:
- Around line 59-61: The current updateEntityFromDTO uses if
(dto.statusInformation() != null) to skip setting statusInformation when the DTO
value is null, which prevents callers from clearing an existing value; to fix,
either (A) always call visa.setStatusInformation(dto.statusInformation()) in
updateEntityFromDTO so a null in the DTO clears the field, or (B) add an
explicit presence indicator to the DTO (e.g., hasStatusInformation() or
Optional<String> statusInformation()) and update updateEntityFromDTO to set
visa.setStatusInformation(...) only when that presence flag indicates the caller
intended to update; update callers accordingly (e.g., VisaService.updateVisa) to
use the chosen approach.
- Around line 15-35: The mapper VisaMapper.toDTO currently sets downloadUrls to
an empty list causing callers to receive incomplete DTOs and forcing
VisaService.findVisaDtoById to reconstruct the entire VisaDTO; change the design
by either (A) adding an overload VisaMapper.toDTO(Visa visa,
Function<String,String> s3KeyToUrl) or toDTO(Visa visa, FileService fileService)
that maps visa.getS3Keys() -> presigned URLs and populates downloadUrls there,
or (B) add a withDownloadUrls(List<String>) method on VisaDTO (a wither) and
have VisaService.findVisaDtoById call visaMapper.toDTO(visa) and then
dto.withDownloadUrls(urls) instead of reconstructing the DTO; update all call
sites (findAll, findVisasByApplicant, findVisaByType, etc.) to use the
appropriate overload or wither so downloadUrls is consistently populated in one
place (refer to VisaMapper.toDTO, VisaService.findVisaDtoById, VisaDTO).

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`:
- Around line 61-89: The findVisaDtoById method manually rebuilds VisaDTO to set
presignedUrls, which is fragile and duplicates VisaMapper.toDTO; instead add a
single helper such as VisaMapper.toDtoWithUrls(Visa visa, FileService
fileService) (or a small private method mapWithUrls) that calls toDTO(visa) and
replaces/downloads the presigned URLs by mapping visa.getS3Keys() via
fileService.getPresignedDownloadUrl, then return that DTO from findVisaDtoById;
update findVisaDtoById to call the new mapper/helper and remove the manual
reconstruction so future VisaDTO fields don’t need to be mirrored here.

In `@src/main/resources/application.properties`:
- Around line 21-22: The multipart size properties
spring.servlet.multipart.max-file-size and
spring.servlet.multipart.max-request-size are set to 10MB but Tomcat can reject
larger uploads first; update configuration to also set
server.tomcat.max-swallow-size and server.tomcat.max-http-form-post-size to
values equal to or larger than the multipart limits (or to -1 for unlimited) so
uploads accepted by VisaViewController’s synchronous streaming won’t be dropped
by Tomcat before Spring processes them; ensure the same limit is applied to both
max-file-size and max-request-size and document the chosen values.

In
`@src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java`:
- Around line 15-18: The Testcontainers configuration is using a mutable image
tag ("postgres:latest") in the PostgreSQLContainer creation inside
TestcontainersConfiguration; replace DockerImageName.parse("postgres:latest")
with a pinned major.minor production-matching tag (e.g., "postgres:16.2" or the
exact version you run in prod) or load that pinned tag from a test
config/property, keeping the same PostgreSQLContainer construction and
.withDatabaseName/.withUsername/.withPassword calls; after updating, run the
provided grep/ripgrep check to ensure no remaining mutable postgres tags remain.

In `@src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java`:
- Around line 80-101: Add two assertions and a new test case exercising
VisaMapper.updateEntityFromDTO's statusInformation branch: create an
UpdateVisaDTO with a non-null statusInformation and assert
existingVisa.getStatusInformation() is updated; also assert in the current test
(or a new test) that when DTO.statusInformation() is null an existing non-null
existingVisa.statusInformation remains unchanged. Reference the UpdateVisaDTO
construction and the visaMapper.updateEntityFromDTO(...) call and assert against
existingVisa.getStatusInformation() to cover both non-null and null branches.

In
`@src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java`:
- Around line 129-148: The test currently uses any() for the S3 key allowing the
controller to skip or ignore FileService.uploadFile(); update the test to stub
fileService.uploadFile(...) to return a fixed S3 key (e.g.
"s3://bucket/key.pdf"), assert fileService.uploadFile was called with the
MockMultipartFile (passportFile), and change the verify of
visaService.applyForVisa(...) to assert that the exact returned S3 key is passed
into the CreateVisaDTO (or passed as the third parameter) instead of any();
locate MockMultipartFile creation, the mockMvc.perform multipart call, and the
verify(visaService).applyForVisa(...) in this test and the similar block at
lines ~224–245 to add the when(...).thenReturn(...) for fileService.uploadFile,
a verify(fileService).uploadFile(...) call, and a stricter verification of the
S3 key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ab82b416-ceb1-4ade-8ae0-f919a6ae77c8

📥 Commits

Reviewing files that changed from the base of the PR and between 14a5c60 and d5d367d.

📒 Files selected for processing (22)
  • compose.yaml
  • pom.xml
  • src/main/java/org/example/visacasemanagementsystem/config/S3Config.java
  • src/main/java/org/example/visacasemanagementsystem/file/FileService.java
  • src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java
  • src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java
  • src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java
  • src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java
  • src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java
  • src/main/resources/application.properties
  • src/main/resources/templates/visa/apply-form.html
  • src/main/resources/templates/visa/details.html
  • src/main/resources/templates/visa/edit-form.html
  • src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java
  • src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java
  • src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java
  • src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java
  • src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java
  • src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java
  • src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java
  • src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java
  • src/test/resources/application-test.properties

Comment thread src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java Outdated
Comment on lines +15 to +22
minio.endpoint=http://localhost:9000
minio.accessKey=admin
minio.secretKey=password
minio.bucketName=visa-documents
minio.region=eu-north-1

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't commit hard-coded MinIO credentials as defaults.

minio.accessKey=admin and minio.secretKey=password are committed literal secrets. Even if these are only intended for local dev against the Compose MinIO, baking them into application.properties (which is also the profile loaded in prod unless overridden) means any deployment that forgets to override them runs with known credentials. Prefer required env vars with no insecure fallback for secrets, mirroring the pattern already used for spring.datasource.*.

🛡️ Suggested change
-minio.endpoint=http://localhost:9000
-minio.accessKey=admin
-minio.secretKey=password
-minio.bucketName=visa-documents
-minio.region=eu-north-1
+minio.endpoint=${MINIO_ENDPOINT:http://localhost:9000}
+minio.accessKey=${MINIO_ACCESS_KEY}
+minio.secretKey=${MINIO_SECRET_KEY}
+minio.bucketName=${MINIO_BUCKET:visa-documents}
+minio.region=${MINIO_REGION:eu-north-1}

Then provide dev defaults via application-test.properties / local .env instead.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
minio.endpoint=http://localhost:9000
minio.accessKey=admin
minio.secretKey=password
minio.bucketName=visa-documents
minio.region=eu-north-1
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
minio.endpoint=${MINIO_ENDPOINT:http://localhost:9000}
minio.accessKey=${MINIO_ACCESS_KEY}
minio.secretKey=${MINIO_SECRET_KEY}
minio.bucketName=${MINIO_BUCKET:visa-documents}
minio.region=${MINIO_REGION:eu-north-1}
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/application.properties` around lines 15 - 22, Remove the
hard-coded MinIO credentials by replacing the literal values for minio.accessKey
and minio.secretKey with required Spring placeholders that read from environment
variables (e.g., ${MINIO_ACCESS_KEY} and ${MINIO_SECRET_KEY}) and fail fast if
not provided (mirror the spring.datasource.* pattern), ensure
minio.endpoint/minio.bucketName remain configurable but do not provide insecure
defaults for secrets, and add local/dev-only defaults in
application-test.properties or a dev .env file instead of committing them in
this file.

Comment on lines +196 to +199
<div th:if="${#lists.isEmpty(visa.downloadUrls)}" style="margin-top: 10px; color: #ff4d4d; font-size: 14px; display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
No documents attached. This may delay processing.
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"No documents attached" message is user-oriented but also shows to admins.

This block unconditionally warns "No documents attached. This may delay processing." — but admins viewing any undocumented case will also see this red warning, which reads as if directed at them. Consider gating the red/warning variant on currentUser.userAuthorization.name() == 'USER' and showing a neutral message for admin viewers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/templates/visa/details.html` around lines 196 - 199, The
"No documents attached" warning is shown to all viewers; change the template
logic to show the red/warning variant only for regular users by checking
currentUser.userAuthorization.name() == 'USER' and render a neutral/info message
for admins otherwise. Locate the existing
th:if="${`#lists.isEmpty`(visa.downloadUrls)}" block in the visa details template
and replace it with a role-gated conditional (use th:if to check empty
downloadUrls AND currentUser.userAuthorization.name() == 'USER' for the red
warning), plus an else/alternate block (e.g., th:if for admin or th:unless for
the USER check) that displays a neutral, non-alarming message when downloadUrls
is empty and the viewer is an admin.

Comment thread src/main/resources/templates/visa/details.html
Comment thread src/main/resources/templates/visa/edit-form.html Outdated
Comment on lines +229 to +234
<div th:each="key, iter : ${visa.s3Keys}">
<form th:id="'delete-doc-' + ${iter.index}"
th:action="@{/visas/{id}/documents/delete(id=${visa.id})}"
method="post" style="display: none;">
<input type="hidden" name="s3Key" th:value="${key}">
</form>

@coderabbitai coderabbitai Bot Apr 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect document delete controller/service code for visa-id + s3Key ownership validation.
rg -n -C5 'documents/delete|removeVisaDocument|delete.*s3Key|getS3Keys\(\)\.contains|s3Keys'

Repository: ithsjava25/visa-case-management-system

Length of output: 11243


Validate that the deleted s3Key belongs to the specified visa before removing.

The s3Key parameter is client-controlled and the delete endpoint does not verify that the key actually belongs to visa.s3Keys. Although ownership is checked (user must be the applicant), an attacker could attempt to delete documents from other visas by providing a valid visa id they own along with an s3Key from a different visa.

Add a check like if (!visa.getS3Keys().contains(s3Key)) throw... before calling fileService.deleteFile(s3Key) in the service method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/templates/visa/edit-form.html` around lines 229 - 234, The
delete endpoint currently trusts the client-supplied s3Key; update the service
method that handles document deletion (the method which calls
fileService.deleteFile(s3Key)) to first verify ownership by checking that the
target Visa's collection contains the key (e.g., if
(!visa.getS3Keys().contains(s3Key)) throw new IllegalArgumentException("s3Key
does not belong to visa") or a domain-specific exception), and only then call
fileService.deleteFile(s3Key) and remove the key from visa; reference the Visa
entity's getS3Keys(), the service delete method that invokes
fileService.deleteFile, and ensure you persist the updated Visa after removal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested fix for this issue?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep --pattern 'public void removeVisaDocument($$$) {
  $$$
}'

Length of output: 0


🏁 Script executed:

#!/bin/bash
rg -n "removeVisaDocument" --type java -A 30

Length of output: 7982


@eeebbaandersson Good news — the current code is partially protected already. On Line 274:

if (visa.getS3Keys().remove(s3Key)) {
    // only runs if the key was actually in this visa's list
    fileService.deleteFile(s3Key);
    ...
}

List.remove() returns false when the key isn't present, so S3 deletion won't happen for a mismatched key. However, the code silently does nothing rather than rejecting the invalid input — which hides errors and is not explicit about intent.

Here is the suggested fix to add an explicit early validation in VisaService.java:

public void removeVisaDocument(Long visaId, String s3Key, Long userId) {
    Visa visa = findVisaById(visaId);

    // Check only the application owner or Admin/sysAdmin can remove files
    User user = userRepository.findById(userId).orElseThrow();
    boolean isOwner = visa.getApplicant().getId().equals(user.getId());
    boolean isAdmin = user.getUserAuthorization() != UserAuthorization.USER;

    if (!isOwner && !isAdmin) {
        throw new UnauthorizedException("You cannot delete this document.");
    }

+   // Verify the key actually belongs to this visa before touching S3
+   if (!visa.getS3Keys().contains(s3Key)) {
+       throw new IllegalArgumentException("Document does not belong to this visa application.");
+   }

    if (visa.getS3Keys().remove(s3Key)) {
        if (visa.getS3Keys().isEmpty()) {
            visa.setVisaStatus(VisaStatus.INCOMPLETE);
        } else {
            visa.setVisaStatus(VisaStatus.SUBMITTED);
        }
        visaRepository.save(visa);

        fileService.deleteFile(s3Key);

        auditService.createAuditLog(userId, visaId, AuditEventType.UPDATED, "Removed document: " + s3Key);
    }
}

This gives you:

  • Defense-in-depth — explicit rejection before any state is touched.
  • Auditability — a clear exception instead of a silent no-op, making it easier to detect tampering attempts in logs.
  • Correctness — the code's intent is explicit to future maintainers rather than relying on a side-effect of List.remove().

🧠 Learnings used
Learnt from: eeebbaandersson
Repo: ithsjava25/visa-case-management-system PR: 27
File: src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java:38-41
Timestamp: 2026-04-14T13:13:34.322Z
Learning: In `visa-case-management-system`, the `processUpdate()` method in `VisaViewController` (`src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java`, around lines 148–152) is missing `model.addAttribute("statusInformation", ...)` in the `IllegalArgumentException` catch block. When `visaService.updateVisa()` throws this exception, the edit form (`edit-form.html`) re-renders without `statusInformation`, causing the "Feedback from Handler" banner to display incorrectly. This is a UI/model bug independent of Spring Security and should be fixed by fetching the current visa's `statusInformation` (e.g., via `visaService.findVisaDtoById(id).statusInformation()`) and adding it to the model before returning the edit view.

Learnt from: eeebbaandersson
Repo: ithsjava25/visa-case-management-system PR: 27
File: src/main/resources/templates/visa/details.html:237-244
Timestamp: 2026-04-14T12:40:29.108Z
Learning: In `visa-case-management-system`, the comment form in `src/main/resources/templates/visa/details.html` (around lines 237-244) currently submits to `/api/comments` with form fields `visaId`, `authorId`, and `content` (wrong field name). The intended fix is to replace the HTML form with JavaScript/fetch to POST JSON to the existing REST endpoint `/api/comments`. The correct JSON body field for the comment text is `text` (matching `CreateCommentDTO`), not `content`. This is a known deferred issue to be addressed in a future PR.

Learnt from: eeebbaandersson
Repo: ithsjava25/visa-case-management-system PR: 16
File: src/main/java/org/example/visacasemanagementsystem/comment/service/CommentService.java:42-43
Timestamp: 2026-04-09T11:13:00.080Z
Learning: In the `visa-case-management-system` repository, the `authorId` field in `CreateCommentDTO` is currently trusted directly in `CommentService.createComment()` (file: `src/main/java/org/example/visacasemanagementsystem/comment/service/CommentService.java`, around line 42). This is a known security gap intentionally deferred until Spring Security is integrated. Once Spring Security is added, the author should be resolved from the authenticated principal (e.g., via `SecurityContextHolder`) rather than from `dto.authorId()`, and the `authorId` field should be removed from `CreateCommentDTO` or restricted to admin use only.

Learnt from: eeebbaandersson
Repo: ithsjava25/visa-case-management-system PR: 27
File: src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java:38-41
Timestamp: 2026-04-14T13:13:34.322Z
Learning: In `visa-case-management-system`, `VisaViewController` (`src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java`) currently accepts `currentUserId` as a `RequestParam` in all handler methods (showDashboard, showApplyForm, submitApplication, showEditForm, processUpdate, approveVisa, requestMoreInformation, rejectVisa, assignCaseToHandler, viewDetails) instead of resolving the authenticated user from the Spring Security context. This is intentionally deferred until Spring Security is fully wired into the web layer. When implemented, `RequestParam Long currentUserId` should be removed from all these methods and replaced with resolution via `SecurityContextHolder.getContext().getAuthentication()` or `AuthenticationPrincipal`, followed by a `userService` lookup to obtain the `UserDTO`.

Learnt from: eeebbaandersson
Repo: ithsjava25/visa-case-management-system PR: 27
File: src/main/resources/templates/visa/dashboard.html:125-126
Timestamp: 2026-04-14T12:26:05.436Z
Learning: In the `visa-case-management-system` templates, ensure the “Sign Out” link/button uses the actual Spring Security logout mechanism once configured. Don’t leave it pointing to an unimplemented `/visas/logout` endpoint. When logout is ready, either (a) use Spring Security’s standard `/logout` endpoint via a POST form (including CSRF token handling), or (b) route to a dedicated controller method (e.g., a `VisaViewController` handler) that delegates to `SecurityContextLogoutHandler`.

Comment thread src/main/resources/templates/visa/edit-form.html

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
src/main/resources/templates/visa/details.html (1)

186-194: substring(visa.s3Keys[iter.index], 37) is brittle — tightly coupled to FileService's key format.

The magic 37 encodes "UUID (36 chars) + _ (1 char)" from FileService.uploadFile. If that prefix ever changes (e.g., switch to a prefix path like visa/<id>/…, or a different id format), this throws StringIndexOutOfBoundsException at render time with no server-side signal. Safer options:

  • Compute the display name in VisaService.findVisaDtoById alongside the presigned URL and expose a parallel fileNames list on VisaDTO.
  • Or do the stripping in a shared helper (e.g., FileService.stripKeyPrefix(key)) so the 37 lives in one place with the key-generation code.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/templates/visa/details.html` around lines 186 - 194, The
template uses a brittle hardcoded substring call
(substring(visa.s3Keys[iter.index], 37)) tied to FileService.uploadFile key
format; move the filename extraction out of the view by either (a) computing and
attaching display names in VisaService.findVisaDtoById and adding a parallel
fileNames list on VisaDTO to render instead of slicing s3Keys, or (b) add a
single helper method in FileService (e.g., FileService.stripKeyPrefix(key)) that
encapsulates the prefix logic and call that from the service layer to populate
VisaDTO, then update the template to read the safe fileNames list (or sanitized
keys) instead of using substring on visa.s3Keys.
src/main/java/org/example/visacasemanagementsystem/file/FileService.java (1)

123-136: Presigned download filename leaks the UUID prefix.

responseContentDisposition uses fileName, which is the raw S3 key (<uuid>_<safeName>). Users who download the document will see e.g. a1b2c3…_passport.pdf as the save-as name. Since the key already has a fixed UUID + "_" prefix (37 chars), you can strip it here to present the original filename. If you ever want to show a true original name, consider persisting it alongside the key.

✏️ Suggested change
-        GetObjectRequest getObjectRequest = GetObjectRequest.builder()
-                .bucket(bucketName)
-                .key(fileName)
-                .responseContentDisposition("attachment; filename=\"" + fileName + "\"")
-                .build();
+        String displayName = fileName.length() > 37 ? fileName.substring(37) : fileName;
+        GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+                .bucket(bucketName)
+                .key(fileName)
+                .responseContentDisposition("attachment; filename=\"" + displayName + "\"")
+                .build();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`
around lines 123 - 136, The presigned download filename leaks the UUID prefix
because getPresignedDownloadUrl uses the raw S3 key (fileName) in
responseContentDisposition; update getPresignedDownloadUrl to derive a
displayName by stripping the fixed UUID + "_" prefix (e.g. split on the first
'_' or drop the first 37 chars) from fileName and use that displayName in
responseContentDisposition so users see the original filename; keep bucketName
and key as-is for the request, and consider persisting an originalName alongside
the key (e.g., in your upload method) if you need a canonical source for the
true original filename.
src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java (2)

61-89: findVisaDtoById destructures the DTO just to swap one field — consider a helper.

Rebuilding the 15-argument VisaDTO record by copying every field from the mapped result is noisy and is a new place to forget a field the next time VisaDTO grows. Two cleaner options:

  • Add a small copy-helper on VisaDTO (e.g. withDownloadUrls(List<String>)) that returns a new record with only that field replaced.
  • Or have VisaMapper.toDTO take an optional List<String> downloadUrls (default List.of()), and compute the URLs before calling the mapper.
♻️ Example (withDownloadUrls on the record)
// In VisaDTO
public VisaDTO withDownloadUrls(List<String> urls) {
    return new VisaDTO(id, visaType, visaStatus, nationality, passportNumber,
            travelDate, applicantId, applicantName, handlerId, handlerName,
            createdAt, updatedAt, statusInformation, urls, s3Keys);
}
-        VisaDTO dto = visaMapper.toDTO(visa);
-
-        List<String> presignedUrls = visa.getS3Keys().stream()
-                .map(fileService::getPresignedDownloadUrl)
-                .toList();
-
-        return new VisaDTO( dto.id(), /* …13 more fields… */ presignedUrls, dto.s3Keys());
+        List<String> presignedUrls = visa.getS3Keys().stream()
+                .map(fileService::getPresignedDownloadUrl)
+                .toList();
+        return visaMapper.toDTO(visa).withDownloadUrls(presignedUrls);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`
around lines 61 - 89, The method findVisaDtoById currently reconstructs a 15-arg
VisaDTO to swap in presignedUrls; instead add a small copy-helper on the VisaDTO
record (e.g. public VisaDTO withDownloadUrls(List<String> urls)) that returns a
new VisaDTO with only the downloadUrls replaced, then change findVisaDtoById to
call visaMapper.toDTO(visa) and then
dto.withDownloadUrls(visa.getS3Keys().stream().map(fileService::getPresignedDownloadUrl).toList())
instead of rebuilding all fields; alternatively, if you prefer the mapper
approach, adjust visaMapper.toDTO to accept an optional List<String>
downloadUrls and pass the computed presignedUrls when calling it.

261-290: removeVisaDocument: the if (remove(s3Key)) is effectively dead, and orElseThrow() is opaque.

A couple of cleanups:

  • Lines 274–276 already throw IllegalArgumentException when the key is not attached, so by the time execution reaches Line 278, remove(s3Key) always returns true. The outer if (and implicit silent no-op branch) can be dropped for clarity.
  • Line 266: userRepository.findById(userId).orElseThrow() throws a bare NoSuchElementException with no message, which surfaces as a vague 500 if userId is stale. Prefer an EntityNotFoundException with a message, matching the pattern used elsewhere in this class.
  • Consider also deleting from S3 after the in-memory remove and before visaRepository.save vs. its current position — current order (save → delete S3) means if fileService.deleteFile fails, the key is already gone from the DB and the audit log still records success. For a dangling orphan that's arguably better than a dangling DB reference, but worth being deliberate about.
♻️ Suggested simplification
-        User user = userRepository.findById(userId).orElseThrow();
+        User user = userRepository.findById(userId)
+                .orElseThrow(() -> new EntityNotFoundException("User not found."));
         boolean isOwner = visa.getApplicant().getId().equals(user.getId());
         boolean isAdmin = user.getUserAuthorization() != UserAuthorization.USER;

         if (!isOwner && !isAdmin) {
             throw new UnauthorizedException("You cannot delete this document.");
         }

         if (!visa.getS3Keys().contains(s3Key)) {
             throw new IllegalArgumentException("Document does not belong to this visa application.");
         }

-        if (visa.getS3Keys().remove(s3Key)) {
-            if (visa.getS3Keys().isEmpty()) {
-                visa.setVisaStatus(VisaStatus.INCOMPLETE);
-            } else {
-                visa.setVisaStatus(VisaStatus.SUBMITTED);
-            }
-            visaRepository.save(visa);
-
-            fileService.deleteFile(s3Key);
-
-            auditService.createAuditLog(userId, visaId, AuditEventType.UPDATED, "Removed document: " + s3Key);
-        }
+        visa.getS3Keys().remove(s3Key);
+        visa.setVisaStatus(visa.getS3Keys().isEmpty() ? VisaStatus.INCOMPLETE : VisaStatus.SUBMITTED);
+        visaRepository.save(visa);
+        fileService.deleteFile(s3Key);
+        auditService.createAuditLog(userId, visaId, AuditEventType.UPDATED, "Removed document: " + s3Key);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`
around lines 261 - 290, In removeVisaDocument, simplify logic by removing the
redundant if around visa.getS3Keys().remove(s3Key) (since you already validate
membership), replace userRepository.findById(userId).orElseThrow() with a clear
EntityNotFoundException including the userId (e.g., throw new
EntityNotFoundException("User not found: " + userId)) and change the operation
order so you remove the s3Key from visa.getS3Keys(), call
fileService.deleteFile(s3Key) immediately after the in-memory removal (so
deletion happens before persisting), then update visa status, call
visaRepository.save(visa), and finally call auditService.createAuditLog(userId,
visaId, AuditEventType.UPDATED, "Removed document: " + s3Key).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`:
- Around line 68-88: The code in FileService.java builds a CORSRule from
corsAllowedOrigins which becomes an empty list when MINIO_CORS is blank, causing
S3 to reject the rule; update the logic (around corsAllowedOrigins/origins and
before CORSRule.builder() / s3Client.putBucketCors calls) to short-circuit when
origins.isEmpty() (skip applying CORS) or fallback to a safe default origin
(e.g., a single allowed origin like "http://localhost" or "*") and only call
CORSRule.builder().allowedOrigins(...) and s3Client.putBucketCors(...) when you
have at least one valid origin to send.
- Around line 54-88: The initializeBucket method in FileService currently
swallows bucket verification/creation failures which lets the subsequent CORS
setup (putBucketCors) run against a nonexistent bucket; update the outer catch
that logs "Failed to verify/create bucket" to stop further execution by either
rethrowing the caught exception (e.g., throw new RuntimeException("Failed to
verify/create bucket", e)) or returning from initializeBucket immediately after
logging, so that the CORS block (the code that builds CORSRule and calls
s3Client.putBucketCors) only runs when headBucket/createBucket succeeded;
reference s3Client.headBucket, s3Client.createBucket, the outer catch around
initializeBucket, and the putBucketCors call when making the change.

In `@src/main/resources/templates/visa/details.html`:
- Around line 378-379: Remove the stray leading "+" characters left from a diff
on the two DOM-manipulating expressions so they are normal statements: change
"+card.append(meta, textEl);" and "+container.prepend(card);" to
"card.append(meta, textEl);" and "container.prepend(card);" respectively (i.e.,
delete the unary-plus tokens affecting card.append and container.prepend).

---

Nitpick comments:
In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`:
- Around line 123-136: The presigned download filename leaks the UUID prefix
because getPresignedDownloadUrl uses the raw S3 key (fileName) in
responseContentDisposition; update getPresignedDownloadUrl to derive a
displayName by stripping the fixed UUID + "_" prefix (e.g. split on the first
'_' or drop the first 37 chars) from fileName and use that displayName in
responseContentDisposition so users see the original filename; keep bucketName
and key as-is for the request, and consider persisting an originalName alongside
the key (e.g., in your upload method) if you need a canonical source for the
true original filename.

In
`@src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java`:
- Around line 61-89: The method findVisaDtoById currently reconstructs a 15-arg
VisaDTO to swap in presignedUrls; instead add a small copy-helper on the VisaDTO
record (e.g. public VisaDTO withDownloadUrls(List<String> urls)) that returns a
new VisaDTO with only the downloadUrls replaced, then change findVisaDtoById to
call visaMapper.toDTO(visa) and then
dto.withDownloadUrls(visa.getS3Keys().stream().map(fileService::getPresignedDownloadUrl).toList())
instead of rebuilding all fields; alternatively, if you prefer the mapper
approach, adjust visaMapper.toDTO to accept an optional List<String>
downloadUrls and pass the computed presignedUrls when calling it.
- Around line 261-290: In removeVisaDocument, simplify logic by removing the
redundant if around visa.getS3Keys().remove(s3Key) (since you already validate
membership), replace userRepository.findById(userId).orElseThrow() with a clear
EntityNotFoundException including the userId (e.g., throw new
EntityNotFoundException("User not found: " + userId)) and change the operation
order so you remove the s3Key from visa.getS3Keys(), call
fileService.deleteFile(s3Key) immediately after the in-memory removal (so
deletion happens before persisting), then update visa status, call
visaRepository.save(visa), and finally call auditService.createAuditLog(userId,
visaId, AuditEventType.UPDATED, "Removed document: " + s3Key).

In `@src/main/resources/templates/visa/details.html`:
- Around line 186-194: The template uses a brittle hardcoded substring call
(substring(visa.s3Keys[iter.index], 37)) tied to FileService.uploadFile key
format; move the filename extraction out of the view by either (a) computing and
attaching display names in VisaService.findVisaDtoById and adding a parallel
fileNames list on VisaDTO to render instead of slicing s3Keys, or (b) add a
single helper method in FileService (e.g., FileService.stripKeyPrefix(key)) that
encapsulates the prefix logic and call that from the service layer to populate
VisaDTO, then update the template to read the safe fileNames list (or sanitized
keys) instead of using substring on visa.s3Keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba544f7d-2b6a-437c-b4fb-15f916a767a8

📥 Commits

Reviewing files that changed from the base of the PR and between d5d367d and 6bf3edf.

📒 Files selected for processing (7)
  • src/main/java/org/example/visacasemanagementsystem/file/FileService.java
  • src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java
  • src/main/resources/application.properties
  • src/main/resources/templates/visa/apply-form.html
  • src/main/resources/templates/visa/details.html
  • src/main/resources/templates/visa/edit-form.html
  • src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/resources/templates/visa/edit-form.html
  • src/main/resources/templates/visa/apply-form.html

Comment thread src/main/resources/templates/visa/details.html Outdated
@eeebbaandersson eeebbaandersson linked an issue Apr 22, 2026 that may be closed by this pull request

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/main/java/org/example/visacasemanagementsystem/file/FileService.java (3)

129-142: Presigned download filename includes the UUID prefix.

responseContentDisposition is set to attachment; filename="<UUID>_<sanitized-original>", so end users downloading a passport document will see the internal UUID in the saved filename. Consider storing the original (sanitized) filename alongside the S3 key (e.g., as object metadata or in the DB) and passing only the user-friendly portion to responseContentDisposition while keeping the UUID in the S3 key for uniqueness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`
around lines 129 - 142, getPresignedDownloadUrl currently sets
responseContentDisposition to the full S3 key (which includes the internal UUID)
so users download filenames like "<UUID>_original"; instead, change
getPresignedDownloadUrl to look up the stored original/sanitized filename (from
object metadata or your DB) and pass only that user-friendly name to
GetObjectRequest.responseContentDisposition while keeping the UUID-prefixed S3
key for bucketName/key; update the method to fetch the original filename (via
your metadata lookup), set responseContentDisposition to "attachment;
filename=\"<originalFilename>\"" and continue using s3Presigner.presignGetObject
with the existing GetObjectPresignRequest.

97-108: Prefer a validation-specific exception over IOException for size/type rejections.

Throwing IOException for user-input validation failures (size exceeded, disallowed content type) conflates client errors with genuine I/O failures. Upstream (VisaViewController) will likely translate this into a 500, whereas these are really 400 Bad Request conditions. Consider IllegalArgumentException or a dedicated InvalidUploadException so the web layer can map it to a proper status and user-friendly message.

♻️ Proposed refactor
-    public String uploadFile(MultipartFile file) throws IOException {
-        // Validate file size
-        if (file.getSize() > MAX_FILE_SIZE_BYTES) {
-            throw  new IOException("File exceeds maximum allowed size of 10 MB");
-        }
-
-        // Validate content type
-        String contentType = file.getContentType();
-        if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
-            throw new IOException("Unsupported document type: " + contentType
-                    + ". Allowed: PDF, JPEG, PNG");
-        }
+    public String uploadFile(MultipartFile file) throws IOException {
+        // Validate file size
+        if (file.getSize() > MAX_FILE_SIZE_BYTES) {
+            throw new IllegalArgumentException("File exceeds maximum allowed size of 10 MB");
+        }
+
+        // Validate content type
+        String contentType = file.getContentType();
+        if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
+            throw new IllegalArgumentException("Unsupported document type: " + contentType
+                    + ". Allowed: PDF, JPEG, PNG");
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`
around lines 97 - 108, Replace the use of IOException for validation failures in
FileService.uploadFile with a validation-specific unchecked exception (create a
new InvalidUploadException extends RuntimeException) so client errors map to
400; keep IOException for real I/O problems in the method signature, but throw
new InvalidUploadException(...) instead of new IOException(...) for the size
check and the content-type check (references: uploadFile method in FileService
and the two throw sites that validate MAX_FILE_SIZE_BYTES and
ALLOWED_CONTENT_TYPES); add the new exception class and import it, and ensure
VisaViewController (or the web layer) can map InvalidUploadException to HTTP
400.

50-50: Hardcoded "test-bucket" sentinel for skipping initialization.

Relying on a magic bucket-name string to detect the test profile is brittle — any production bucket accidentally named test-bucket would silently skip initialization, and vice versa. Prefer a Spring profile check (@Profile/@ConditionalOnProperty) or an explicit flag like minio.initializeBucket=false in application-test.properties.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java` at
line 50, The FileService currently uses a hardcoded sentinel check (bucketName
== null || bucketName.equals("test-bucket")) to skip bucket initialization;
replace that brittle logic with a proper profile/property-driven guard: remove
the "test-bucket" string check in the initialization path (the code referencing
bucketName in FileService) and instead inject/consume a Spring boolean property
(e.g., minio.initializeBucket) or annotate the bean with
`@Profile/`@ConditionalOnProperty so initialization only runs when explicitly
enabled; update the initialization method/constructor (initBucket or whatever
method performs creation) to consult this property/annotation and fall back to
checking only for null bucketName, ensuring production buckets named
"test-bucket" are not treated specially.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/main/java/org/example/visacasemanagementsystem/file/FileService.java`:
- Around line 129-142: getPresignedDownloadUrl currently sets
responseContentDisposition to the full S3 key (which includes the internal UUID)
so users download filenames like "<UUID>_original"; instead, change
getPresignedDownloadUrl to look up the stored original/sanitized filename (from
object metadata or your DB) and pass only that user-friendly name to
GetObjectRequest.responseContentDisposition while keeping the UUID-prefixed S3
key for bucketName/key; update the method to fetch the original filename (via
your metadata lookup), set responseContentDisposition to "attachment;
filename=\"<originalFilename>\"" and continue using s3Presigner.presignGetObject
with the existing GetObjectPresignRequest.
- Around line 97-108: Replace the use of IOException for validation failures in
FileService.uploadFile with a validation-specific unchecked exception (create a
new InvalidUploadException extends RuntimeException) so client errors map to
400; keep IOException for real I/O problems in the method signature, but throw
new InvalidUploadException(...) instead of new IOException(...) for the size
check and the content-type check (references: uploadFile method in FileService
and the two throw sites that validate MAX_FILE_SIZE_BYTES and
ALLOWED_CONTENT_TYPES); add the new exception class and import it, and ensure
VisaViewController (or the web layer) can map InvalidUploadException to HTTP
400.
- Line 50: The FileService currently uses a hardcoded sentinel check (bucketName
== null || bucketName.equals("test-bucket")) to skip bucket initialization;
replace that brittle logic with a proper profile/property-driven guard: remove
the "test-bucket" string check in the initialization path (the code referencing
bucketName in FileService) and instead inject/consume a Spring boolean property
(e.g., minio.initializeBucket) or annotate the bean with
`@Profile/`@ConditionalOnProperty so initialization only runs when explicitly
enabled; update the initialization method/constructor (initBucket or whatever
method performs creation) to consult this property/annotation and fall back to
checking only for null bucketName, ensuring production buckets named
"test-bucket" are not treated specially.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 63a9979a-64eb-496e-9ad0-8ea5d32d9d85

📥 Commits

Reviewing files that changed from the base of the PR and between 6bf3edf and 321214b.

📒 Files selected for processing (2)
  • src/main/java/org/example/visacasemanagementsystem/file/FileService.java
  • src/main/resources/templates/visa/details.html
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/resources/templates/visa/details.html

@eeebbaandersson
eeebbaandersson merged commit a51c88c into main Apr 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement logic for S3 Integration Upload/Download

1 participant